模块与包

  通常,Lua不会设置规则(policy)。相反,Lua会提供许多强有力的机制来使开发者有能力实现出最适合的规则。然而,这种方法对于模块就不可行了。模块系统的一个主要目标是允许以不同的形式来共享代码。但若没有一项公共的规则就无法实现这样的共享。

  Lua从5.1开始,为模块和包(package)定义了一系列的规则。这些规则不需要语言引入额外的技能,程序员可以使用他们早已熟知的table、函数、元表和环境来实现这些规则。然而,有两个重要的函数可以很容易通过这些规则,它们是require(用于使用模块)和module(用于创建模块)。程序员完全可以使用不同的规则来重新实现这两个函数。但是,新的实现可能会使程序无法使用外部模块,或者编写的模块无法被外部程序所使用。

  从用户观点来看,一个模块就是一个程序库,可以通过require来加载。然后便得到了一个全局变量,表示一个table。这个table就像是一个名称空间,其内容就是模块中导出的所有东西,例如函数和常量。一个规范的模块还应使require返回这个table

  使用table来实现模块的优点在于,可以像操作普通table那样来操作模块,并且能利用Lua现有的功能来实现各种额外的功能。在大多数语言中,模块不是“第一类值(first-class value)”,所以那些语言需要为模块实现一套专门的机制。在Lua中,可以轻易地实现所有这些功能。

  例如,一个用户要调用一个模块中的函数。其中最简单的方法是:

    require "mod"
    mod.foo()

  如果希望使用较短的模块名称,则可以为模块设置一个局部名称:

    local m = require "mod"
    m.foo()

  还可以为个别函数提供不同的名称:

    require "mod"
    local f = mod.foo
    f()

  上述这些方法,都不需要来自于语言的显式支持,只需使用语言现有的内容。

  

require函数

  Lua提供了一个名为require的高层函数用来加载模块,但这个函数只假设了关于模块的基本概念。对于require而言,一个模块就是一段定义了一些值的代码。

  要加载一个模块,只需简单地调用require "<模块名>"。该调用会返回一个由模块函数组成的table,并且还会定义一个包含该table的全局变量。然而,这些行为都是由模块完成的,而非require。所以,有些模块会选择返回其他值,或者具有其他的效果。

  即使知道某些用到的模块可能已加载了,但只要用到require就是一个良好的编程习惯。可以将标准库排除在此规则之外,因为Lua总是会预先加载它们。不过,有些用户还是喜欢为标准库中的模块使用显式的require

    local m = require "io"
    m.write("hello world\n")

  以下代码详细说明了require的行为:

    function require(name)
        if not packag.loaded[name] then        -- 模块是否已加载?
            local loader = findloader(name)
            if loader == nil then
                error("unable to load module " .. name)
            end
            package.loaded[name] = true        -- 将模块标记为已加载
            local res = loader(name)           -- 初始化模块
            if res ~= nil then
                package.loaded[name] = res
            end
        end
        return package.loaded[name]
    end

  首先,它在table package.loaded中检查模块是否已加载。如果是的话,require就返回相应的值。因此,只要一个模块已加载过,后续的require调用都将返回同一个值,不会再次加载它。

  如果模块尚未加载,require就试着为该模块找一个加载器(loader),会先在table package.preload中查询传入的模块名。如果在其中找到了一个函数,就以该函数作为模块的加载器。通过这个preload table,就有了一种通用的方法来处理各种不同的情况。通常这个table中不会找到有关指定模块的条目,那么require就会尝试从Lua文件或C程序库中加载模块。

  如果require为指定模块找到了一个Lua文件,它就通过loadfile来加载该文件。而如果找到的是一个C程序库,就通过loadlib来加载。注意,loadfileloadlib都只是加载了代码,并没有运行它们。为了运行代码,require会以模块名作为参数来调用这些代码。如果加载器有返回值,require就将这个返回值存储到table package.loaded中,以此作为将来对同一模块调用的返回值。如果加载器没有返回值,require就会返回table package.loaded中的值。在本章后面会看到,一个模块还可以将返回给require的值直接放入package.loaded中。

  上述代码中还有一个重要的细节,就是在调用加载器前,require先将true赋予了package.loaded中的对应字段,以此将模块标记为已加载。这是因为如果一个模块要求加载另一个模块,而后者又要递归地加载前者。那么后者的require调用就会马上返回,从而避免了无限循环。

  若要强制使require对用一个库加载两次的话,可以简单地删除package.loaded中的模块条目。例如,在成功地require "foo"后,package.loaded["foo"]就不为nil了。下面代码就可以再次加载该模块:

    package.loaded["foo"] = nil
    require "foo"

  在搜索一个文件时,require所使用的路径与传统的路径有所不同。大部分程序所使用的路径就是一连串目录,指定了某个文件的具体位置。然而,ANSIC却没有任何关于目录的概念。所以,require采用的路径是一连串的模式(pattern),其中每项都是一种将模块名转换为文件名的方式。进一步说,这种路径中的每项都是一个文件名,每项中还包含一个可选的问号。require会用模块名来替换每个“?”,然后根据替换的结果来检查是否存在这样一个文件。如果不存在,就会尝试下一项。路径中的每项以分号隔开。例如,假设路径为:

    ?;?.lua;c:\windows\?;/usr/local/lua/?/?.lua

  那么,调用require "sql"就会试着打开以下文件:

    sql
    sql.lua
    c:\windows\sql
    /usr/local/lua/sql/sql.lua

  require函数只处理了分号(作为各项之间的分隔符)和问号。其他例如目录分隔符或文件扩展名,都由路径自己定义。

  require用于搜索Lua文件的路径存放在变量package.path中。当Lua启动后,便以环境变量LUA_PATH的值来初始化这个变量。如果没有找到该环境变量,则使用一个编译时定义的默认路径来初始化。在使用LUA_PATH时,Lua会将其中所有的子串“;;”替换成默认路径。例如,假设LUA_PATH为“mydir/?.lua;;”,那么最终路径就是“mydir/?.lua”,并紧随默认路径。

  如果require无法找到与模块名相符的Lua文件,它就会找C程序库。这类搜索会从变量package.cpath(相对于package.path)获取路径。而这个变量则是通过环境变量LUA_CPATH(相对于LUA_PATH)来初始化。在UNIX中,它的值一般是这样的:

    ./?.so;/usr/local/lib/lua/5.1/?.so

  注意,文件的扩展名是由路径定义的(例如,上例中使用的.so)。而在Windows中,此路径通常可以是这样的:

    .\?.dll;C:\Program Files\Lua501\dll\?.dll

  当找到一个C程序库后,require就会通过package.loadlib来加载它,loadlib在前面章节中已讨论过。C程序库与Lua程序块是不同的,它没有定义一个单一的主函数,而是导出了几个C函数。具有良好行为的C程序库应该导出一个名为“luaopen_<模块名>”的函数。require会在链接完程序库后,尝试调用这个函数。将在后续章节中讨论如何编写C程序库。

  一般通过模块的名称来使用它们。但有时必须将一个模块改名,以避免冲突。一种典型的情况是,在测试中需要加载同一模块的不同版本。对于一个Lua模块来说,其内部名称不是固定的,可以轻易地编辑它以改变其名称。但是却无法编辑一个二进制数据模块中luaopen_*函数的名称。为了允许这种重命名,require用到了一个小技巧:如果一个模块名中包含了连字符,require就会用连字符后的内容来创建luaopen_*函数名。例如,若一个模块名为a-brequire就认为它的open函数名为luaopen_b,而不是luaopen_a-b。因此,如果要使用的两个模块名都为mod,那么可以将其中一个重命名为v1-mod(或者-mod,或其他类似形式)。当调用m1 = require "v1-mod"时,require会找到改名后的文件v1-mod,并将其中的函数luaopen_mod作为open函数。

  

编写模块的基本方法

  在Lua中创建一个模块最简单的方法是:创建一个table,并将所有需要导出的函数放入其中,最后返回这个table。以下代码演示这种方法。注意,将inv声明为程序块的局部变量,就是将其定义成一个私有的名称。

    complex = {}

    function complex.new(r, i) return {r=r, i=i} end

    -- 定义一个常量'i'
    complex.i = complex.new(0, 1)

    function complex.add(c1, c2)
        return complex.new(c1.r  c2.r, c1.i + c2.i)
    end

    function complex.sub(c1, c2)
        return complex.new(c1.r - c2.r, c1.i - c2.i)
    end

    function complex.mul(c1, c2)
        return complex.new(c1.r*c2.r - c1.i*c2.i,
                           c1.r*c2.i + c1.i*c2.r)
    end

    local function inv(c)
        local n = c.r^2 + c.i^2
        return complex.new(c.r/n, -c.i/n)
    end

    function complex.div(c1, c2)
        return complex.mul(c1, inv(c2))
    end

    return complex

  上例中使用table编写模块时,没有提供与真正模块完全一致的功能性,首先,必须显式地将模块名放到每个函数定义中。其次,一个函数在调用同一模块中的另一个函数时,必须限定被调用函数的名称。可以使用一个固定的局部名称(例如M)来定义和调用模块内的函数,然后将这个局部名称赋予模块的最终名称。通过这种方法,可以将上例改写为:

    local M = {}
    complex = M                     -- 模块名

    M.i = {r=0, i=1}
    function M.new(r, i) return {r=r, i=i} end

    function M.add(c1, c2)
        return M.new(c1.r + c2.r, c1.i + c2.i)
    end
    <如前>

  只要一个函数调用了同一模块中另一个函数(或者递归地调用自己),就仍需要一个前缀名称。但至少两个函数之间的连接不再需要依赖模块名,并且也只需在整个模块中的一处写出模块名。实际上,可以完全避免写模块名,因为require会将模块名作为参数传给模块:

    local modname = ...
    local M = {}
    _G[modname] = M

    M.i = {r=0, i=1}
    <如前>

  经过这样的修改,若需要重命名一个模块,只需重命名并定义它的文件就可以了。

  另一项小改进与结尾的return语句有关。若能将所有与模块相关的设置任务集中在模块开头,会更好。消除return语句的一种方法是,将模块table直接赋予package.loaded

    local modname = ...
    local M = {}
    _G[modname] = M
    package.loaded[modname] = M
    <如前>

  通过这样的赋值,就不需要在模块结尾返回M了。注意,如果一个模块无返回值的话,require就会返回package.loaded[modname]的当前值。

  

使用环境

  创建模块的基本方法的缺点在于,它要求程序员投入一些额外的关注。当访问同一模块中的其他公共实体时,必须限定其名称。并且,只要一个函数的状态从私有改为公有(或从公有改为私有),就必须修改调用。另外,在私有声明中也很容易忘记关键字local

  “函数环境”是一种有趣的技术,它能够解决所有上述创建模块时遇到的问题。基本想法就是让模块的主程序块有一个独占的环境。这样不仅它的所有函数都可共享这个table,而且它的所有全局变量也都记录在这个table中。还可以将所有公有函数声明为全局变量,这样它们就都自动地记录在一个独立的table中了。模块所要做的就是将这个table赋予模块名和package.loaded。以下代码片段演示了这种技术:

    local modname = ...
    local M = {}
    _G[modname] = M
    package.loaded[modname] = M
    setfenv(1, M)

  此时,当声明函数add时,它就称为了complex.add

    function add(c1, c2)
        return new(c1.r + c2.r, c1.i + c2.i)
    end

  此外,在调用同一模块的其他函数时,也不再需要前缀。例如,add会从其环境中得到new,也就是complex.new

  这种方法为模块提供了一种良好的支持,并且只引入了一点额外的工作。此时完全不需要前缀,并且调用一个导出的函数与调用一个私有函数没有什么区别。如果程序员忘记了写local关键字,那么也不会污染全局名称空间。只会将一个私有函数变成了公有而已。

  还缺少什么?是的,那就是访问其他模块。当创建了一个空table M作为环境后,就无法访问前一个环境中全局变量了。以下提出几种重获访问的方法,每种方法各有其优缺点。

  最简单的方法是继承,就像之前看到的那样:

    local modname = ...
    local M = {}
    _G[modname] = M
    package.loaded[modname] = M
    setmetatable(M, {__index = _G})
    setfenv(1, M)

  必须先调用setmetatable再调用setfenv,因为通过这种方法,模块就能直接访问任何全局标识了,每次访问只需付出很小的开销。这种方法导致了一个后果,即从概念上说,此时的模块中包含了所有的全局变量。例如,某人可以通过你的模块来调用标准的正弦函数:complex.math.sin(x)

  还有一种更便捷的方法来访问其他模块,即声明一个局部变量,用以保存对旧环境的访问:

    local modname = ...
    local M = {}
    _G[modname] = M
    package.loaded[modname] = M
    local _G = _G
    setfenv(1, M)

  此时必须在所有全局变量的名称前加“_G.”。由于没有涉及到元方法,这种访问会比前面的方法略快。

  一种更正规的方法是将那些需要用到的函数或模块声明为局部变量:

    -- 模块设置
    local modname = ...
    local M = {}
    _G[modname] = M
    package.loaded[modname] = M

    -- 导入段:
    -- 声明这个模块从外界所需的所有东西
    local sqrt = math.sqrt
    local io = io

    -- 在这句之后就不再需要外部访问了
    setfenv(1, M)

  这种技术要求做更多的工作,但是它能清晰地说明模块的依赖性。同时,较之前面的两种方法,它的运行速度也更快。

  

module函数

  读者或许注意到了,前面几个示例中的代码形式。它们都以相同的模式开始:

    local modname = ...
    local M = {}
    _G[modname] = M
    package.loaded[modname] = M
    <setup for external access>
    setfenv(1, M)

  Lua5.1提供了一个新函数module,它囊括了以上这些功能。在开始编写一个模块时,可以直接用以下代码来取代前面的设置代码:

    module(...)

  这句调用会创建一个新的table,并将其赋予适当的全局变量和loaded table,最后还会将这个table设为主程序块的环境。

  默认情况下,module不提供外部访问。必须在调用它前,为需要访问的外部函数或模块声明适当的局部变量。也可以通过继承来实现外部访问,只需在调用module时加一个选项package.seeall。这个选项等价于以下代码:

    setmetatable(M, {__index = _G})

  因而只需这么做:

    module(..., package.seeall)

  在一个模块文件的开头有了这句调用后,后续所有的代码都可以像普通的Lua代码那样编写了。不需要限定模块名和外部名字,同样也不需要返回模块table。要做的只是加上这么一句调用。

  module函数还提供了一些额外的功能。虽然大部分模块不需要这些功能,但有些发行模块可能需要一些特殊处理(例如,一个模块中同时包含C函数和Lua函数)。module在创建模块table之前,会先检查package.loaded是否已包含了这个模块,或者是否已存在与模块同名的变量。如果module由此找到了这个table,它就会复用该table作为模块。也就是说,可以用module来打开一个已创建的模块。如果没有找到模块tablemodule就会创建一个模块table。然后在这个table中设置一些预定义的变量,包括:_M,包含了模块table自身(类似于_G);_NAME,包含了模块名(传给module的第一个参数);_PACKAGE,包含了包(package)的名称。

  

子模块与包

  Lua支持具有层级性的模块名,可以用一个点来分隔名称中的层级。假设,一个模块名为mod.sub,那么它就是mod的一个子模块。因此,可以认为模块mod.sub会将其所有值都定义在table mod.sub中,也就是一个存储在table mod中且keysubtable。一个“包(Package)”就是一个完整的模块树,它是Lua中发行的单位。

  当require一个模块mod.sub时,require会用原始的模块名“mod.sub”作为key来查询table package.loadedpackage.preload,其中,模块名中的点在搜索中没有任何含义。

  然而,当搜索一个定义子模块的文件时,require会将点转换为另一个字符,通常就是系统的目录分隔符。转换之后require就像搜索其他名称一样来搜索这个名称。例如,假设路径为:

    ./?.lua;/usr/local/lua/?.lua;/usr/local/lua/?/init.lua

  并且目录分隔符为“/”,那么调用require "a.b"就会尝试打开以下文件:

    ./a/b.lua
    /usr/local/lua/a/b.lua
    /usr/local/lua/a/b/init.lua

  通过这样的加载策略,就可以将一个包中的所有模块组织到一个目录中。例如,一个包中有模块pp.ap.b,那么它们对应的文件名就分别为p/init.luap/a.luap/b.lua,它们都是目录p下的文件。

  Lua使用的目录分隔符是编译时配置的,可以是任意的字符串。例如,在没有目录层级的系统中,就可以使用“_”作为“目录分隔符”。那么require "a.b"就会搜索到文件a_b.lua

  C函数名中不能包含点,因此一个用C编写的子模块a.b无法导出函数luaopen_a.b。所以,require会将点转换为下划线。例如,一个名为a.b的C程序库就应将其初始化函数命名为luaopen_a_b。在此又可以巧用连字符,来实现一些特殊的效果。例如,有一个C程序库a,现在想将它作为mod的一个子模块,那么就可以将文件名改为mod/-a。当执行require "mod.-a"时,require就会找到改名后的文件mod/-a及其中的函数luaopen_a

  作为一项扩展功能,require在加载C子模块时还有一些选项。当require加载子模块时,无法找到对应的Lua文件或C程序库。它就会再次搜索C路径,不过这次将以包的名称来查找。例如,一个程序require子模块a.b.c,当无法找到文件a/b/c时,再次搜索就会找到文件a。如果找到了C程序库arequire就查看该程序库中是否有open函数luaopen_a_b_c。这项功能使得一个发行包可以将几个子模块组织到一个单一C程序库中,并且具有各自的open函数。

  module函数也为子模块提供了显式的支持。当我们创建一个子模块时,调用module ("a.b.c")module就会将环境table放入变量a.b.c,也就是“table a中的table b中的table c”。如果这些中间的table不存在,module就会创建它们。否则,就复用它们。

  从Lua的观点看,同一个包中的子模块除了它们的环境table是嵌套的之外,它们之间并没有显式的关联性。require模块a并不会自动地加载它的任何子模块。同样,require子模块a.b也并不会自动地加载a。当然只要愿意,包的实现者完全可以实现这种关联。例如,模块a的一个子模块在加载时会显式地加载a

🔚

results matching ""

    No results matching ""